Skip to main content

Unit Tests

Unittest provides developers with a set of tools to construct and run tests on individual components or units of code to ensure their correctness. By running unittests, developers can identify and fix bugs, creating more reliable code.

Concepts

Unittest relies on the following concepts:

  • Test Fixture: Prepares the environment to perform one or more tests, including any necessary cleanup actions. This could involve creating temporary databases, directories, or starting server processes.
  • Test Case: An individual unit of testing that checks for a specific response to a set of inputs. The TestCase class provided by unittest can be used to create new test cases.
  • Test Suite: A collection of test cases or test suites that should be executed together.
  • Test Runner: Executes the tests and provides the outcome to the developer. It can use different interfaces, like graphical or textual, to present the test results.

Use Case

Let's look at a test case example where Python code simulates a cake factory performing different functions, such as choosing different sizes and flavors of a cake, adding toppings, returning a list of ingredients, and calculating the price.

from typing import List

class CakeFactory:
def __init__(self, cake_type: str, size: str):
self.cake_type = cake_type
self.size = size
self.toppings = []

# Price based on cake type and size
self.price = 10 if self.cake_type == "chocolate" else 8
self.price += 2 if self.size == "medium" else 4 if self.size == "large" else 0

def add_topping(self, topping: str):
self.toppings.append(topping)
# Adding 1 to the price for each topping
self.price += 1

def check_ingredients(self) -> List[str]:
ingredients = ['flour', 'sugar', 'eggs']
if self.cake_type == "chocolate":
ingredients.append('cocoa')
else:
ingredients.append('vanilla extract')
ingredients += self.toppings
return ingredients

def check_price(self) -> float:
return self.price

# Example of creating a cake and adding toppings
cake = CakeFactory("chocolate", "medium")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
cake_ingredients = cake.check_ingredients()
cake_price = cake.check_price()

cake_ingredients, cake_price

In the code above, the CakeFactory class and its methods are defined. Now, let's define the unittest methods to test the different functions of the code. The test suite includes tests for the cake's flavor, size, toppings, ingredients, and price.

import unittest

class TestCakeFactory(unittest.TestCase):
def test_create_cake(self):
cake = CakeFactory("vanilla", "small")
self.assertEqual(cake.cake_type, "vanilla")
self.assertEqual(cake.size, "small")
self.assertEqual(cake.price, 8) # Vanilla cake, small size

def test_add_topping(self):
cake = CakeFactory("chocolate", "large")
cake.add_topping("sprinkles")
self.assertIn("sprinkles", cake.toppings)

def test_check_ingredients(self):
cake = CakeFactory("chocolate", "medium")
cake.add_topping("cherries")
ingredients = cake.check_ingredients()
self.assertIn("cocoa", ingredients)
self.assertIn("cherries", ingredients)
self.assertNotIn("vanilla extract", ingredients)

def test_check_price(self):
cake = CakeFactory("vanilla", "large")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
price = cake.check_price()
self.assertEqual(price, 13) # Vanilla cake, large size + 2 toppings

# Running the unittests
unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromTestCase(TestCakeFactory))

This results in the output:

..F.
======================================================================
FAIL: test_check_price (__main__.TestCakeFactory)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<ipython-input-9-32dbf74b3655>", line 33, in test_check_price
self.assertEqual(price, 13) # Vanilla cake, large size + 2 toppings
AssertionError: 14 != 13

----------------------------------------------------------------------
Ran 4 tests in 0.007s

FAILED (failures=1)
<unittest.runner.TextTestResult run=4 errors=0 failures=1>

The test for test_check_price failed because the expected price was incorrect. The cake price should have been 14, not 13. We can correct that part of the test:

def test_check_price(self):
cake = CakeFactory("vanilla", "large")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
price = cake.check_price()
self.assertEqual(price, 14) # Vanilla cake, large size + 2 toppings

Re-running the unittests now gives:

....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
<unittest.runner.TextTestResult run=4 errors=0 failures=0>

Key Takeaways

Unittest assists developers in building robust and effective code. It allows testing of small, isolated units of functionality to catch bugs early and ensure consistent behavior.

Writing Unit Tests in Python

To write unit tests in Python, we use the unittest module, which provides a framework for constructing and running tests.

Example: Testing a Name Rearrangement Function

Suppose we have a function rearrange_name that rearranges names from "Last, First" format to "First Last":

import re

def rearrange_name(name):
result = re.search(r"^([\w .]*), ([\w .]*)$", name)
if result is None:
return name
return "{} {}".format(result[2], result[1])

We can create a test file rearrange_test.py to test this function:

import unittest
from rearrange import rearrange_name

class TestRearrange(unittest.TestCase):
def test_basic(self):
testcase = "Lovelace, Ada"
expected = "Ada Lovelace"
self.assertEqual(rearrange_name(testcase), expected)

if __name__ == '__main__':
unittest.main()

Running the tests:

python rearrange_test.py

The output will indicate whether the tests have passed or failed.

Edge Cases

Edge cases are inputs that fall at the extreme ends of the spectrum of possible inputs. They can often cause unexpected behavior if not properly handled.

Handling Empty Input

Let's test how the function behaves with an empty string:

def test_empty(self):
testcase = ""
expected = ""
self.assertEqual(rearrange_name(testcase), expected)

If this test fails, we need to modify the rearrange_name function to handle empty input appropriately.

Modifying the Function

We can update the function to return the original name if the regex search doesn't find a match:

def rearrange_name(name):
result = re.search(r"^([\w .]*), ([\w .]*)$", name)
if result is None:
return name
return "{} {}".format(result[2], result[1])

Now, the test for the empty input should pass.

Additional Test Cases

Testing Names with Middle Names

Let's add a test case for names with middle names or initials:

def test_double_name(self):
testcase = "Hopper, Grace M."
expected = "Grace M. Hopper"
self.assertEqual(rearrange_name(testcase), expected)

Testing Names Without Comma

Testing names that don't contain a comma:

def test_one_name(self):
testcase = "Voltaire"
expected = "Voltaire"
self.assertEqual(rearrange_name(testcase), expected)

After adding these tests, we can run the test suite to ensure all tests pass.

Pytest

Pytest is a powerful Python testing tool that simplifies writing, organizing, and executing tests. It supports automatic test discovery and generates informative test reports.

Writing Tests with Pytest

Pytest uses simple assert statements for writing tests, making tests easier to read and write.

Example:

def divide(a, b):
assert b != 0, "Cannot divide by zero"
return a / b

An AssertionError is raised if the condition b != 0 is false.

Pytest Fixtures

Fixtures are reusable pieces of test setup and teardown code shared across multiple tests.

Example:

import pytest

class Fruit:
def __init__(self, name):
self.name = name
self.cubed = False

def cube(self):
self.cubed = True

class FruitSalad:
def __init__(self, *fruit_bowl):
self.fruit = fruit_bowl
self._cube_fruit()

def _cube_fruit(self):
for fruit in self.fruit:
fruit.cube()

@pytest.fixture
def fruit_bowl():
return [Fruit("apple"), Fruit("banana")]

def test_fruit_salad(fruit_bowl):
fruit_salad = FruitSalad(*fruit_bowl)
assert all(fruit.cubed for fruit in fruit_salad.fruit)

In this example, fruit_bowl is a fixture that provides test data to the test_fruit_salad test function.

Key Takeaways

  • Pytest allows for simple and clear test writing using assert statements.
  • Fixtures help in sharing common test data and configurations across multiple tests.

Comparing Unittest and Pytest

Both unittest and pytest provide tools for creating robust and reliable code through different forms of tests.

Key Differences

Featureunittestpytest
InclusionBuilt into PythonExternal library (requires installation)
Test DiscoveryRequires command-line invocationAutomatic using test_ prefix
Test StyleObject-oriented (classes and methods)Functional (simple functions)
AssertionsSpecial methods like assertEqual()Uses standard assert statements
CompatibilityBackward compatibility with pytestCan run unittest tests

When to Use Each

  • unittest: Preferred if you want a framework that's built into Python and if you prefer an object-oriented approach.
  • pytest: Preferred for its simplicity and powerful features like fixtures and plugins.

Key Takeaways

Both unittest and pytest are beneficial for executing tests in Python. The choice between them depends on the developer's preference and the specific needs of the project.

Best Practices for Unit Testing

  • Isolation: Tests should be isolated to ensure any success or failure is caused by the unit being tested, not external factors.
  • Avoid Modifying Production Environment: Tests should not modify the live production environment.
  • Edge Cases: Include tests for edge cases and unusual inputs to ensure robust code.
  • Automatic Testing: Use test runners and frameworks to automate the testing process.
  • Consistent Testing Conditions: Use fixtures to maintain consistent testing conditions.